문단 클릭 = 시작 위치 선택 · 재생은 우하단 버튼/P (재생 중 클릭 = 점프)

4장. 검색과 응답을 최적화하는 RAG 고도화 전략

출처 — 브라이스 유·조경아·박수진·김재웅, 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(프리렉 2025), 4장 (pp. 207~298). 원문 PDF rag_master_final_v11_260910.pdf (2026-09-10 판)

RAG 파이프라인의 검색(Retrieval)과 생성(Generation) 양쪽을 고도화하는 다섯 가지 전략 — 청킹 전략(부모-자식 분할), 질의 변형(다중 질의 생성·HyDE), 검색 알고리즘(희소·밀집·앙상블), 문서 후처리(LLM 기반·크로스 인코더 기반 리랭킹), Self-RAG — 를 원리와 랭체인 실습 코드로 다룬다.

학습 목표

이 장을 끝내면 다음을 할 수 있다.

  • 부모-자식 분할이 재귀적 문자 분할·의미 기반 분할의 한계를 어떻게 보완하는지 설명한다.
  • 다중 질의 생성과 HyDE의 동작 원리 차이를 구분하고, 질문 성격에 따라 어느 쪽을 쓸지 판단한다.
  • 희소 검색(BM25)과 밀집 검색(FAISS)의 원리를 비교하고, 앙상블 검색으로 두 방식을 결합해 구현한다.
  • LLM 기반 리랭킹과 크로스 인코더 기반 리랭킹의 정확도·속도·비용 트레이드오프를 비교해 상황에 맞게 선택한다.
  • Self-RAG가 검색·생성·평가 단계에서 특수 토큰으로 스스로를 제어하는 원리를 설명한다.

전체 흐름도

[ 원본 문서 ]
      │
      ▼
[ 1. 청킹 전략 ]  문서를 검색에 최적화된 단위로 분할
      │  부모-자식 분할 — 부모(큰 단위, 넓은 맥락) + 자식(작은 단위, 정확한 매칭)
      ▼
[ 2. 질의 변형 ]  사용자의 원래 질문 → 검색에 효율적인 형태로 재구성
      ├─ 다중 질의 생성 — 질문을 여러 관점으로 확장(LLM) → 병렬 검색 → 결과 통합
      └─ HyDE — 질문이 아니라 "질문에 대한 가상의 답변"을 만들어 그것으로 검색
      ▼
[ 3. 검색 알고리즘 ]  변형된 질의로 관련 문서를 찾는다
      ├─ 희소 검색(BM25) — 키워드 일치 기반, 빠르고 계산이 가볍다. 의미는 못 본다
      ├─ 밀집 검색(FAISS) — 의미 벡터 유사도 기반, 문맥을 본다. 계산이 무겁다
      └─ 앙상블 검색 — 둘의 결과를 가중치로 결합(Reciprocal Rank Fusion)
      ▼
[ 4. 문서 후처리(리랭킹) ]  1차 검색 결과의 관련성을 한 번 더 정밀 평가
      ├─ LLM 기반 리랭킹 — 정확도가 높다. 비용·지연이 크다
      └─ 크로스 인코더 기반 리랭킹 — 속도가 빠르다. 정확도는 LLM보다 낮을 수 있다
      ▼
[ 5. 확장된 RAG 방법론: Self-RAG ]  모델이 스스로 검색 필요·관련성·근거·유용성을 판단
      │  Retrieve(검색할까?) → ISREL(관련 있나?) → 생성 → ISSUP(근거 있나?) + ISUSE(쓸모있나?) → 최선 응답 선택
      ▼
[ 최종 응답 ]

0. 용어 사전

참고 — 위쪽 4개는 이 장을 읽기 전에 알아야 하는 선행 용어다. 2장에서 이미 다뤘고, 이 장은 이를 전제로 청킹·검색 알고리즘을 설명한다.

한글 용어 원문 영문명 의미
텍스트 임베딩 Text Embedding (선행) 텍스트를 의미를 반영한 고차원 벡터로 변환하는 것. 2장 1절에서 소개됐고, 이 장의 밀집 검색·크로스 인코더가 모두 이 개념 위에서 동작한다. 본문 §3.2
코사인 유사도 Cosine Similarity (선행) 두 벡터가 이루는 각도로 유사성을 재는 척도. 2장에서 소개됐고, 이 장의 희소·밀집 검색 모두 유사도 계산에 이 방식을 쓴다. 본문 §3
재귀적 문자 텍스트 분할 Recursive Character Text Splitting (선행) 문서 구조(문단·문장)를 고려해 정해진 크기로 문서를 나누는 분할 방식. 2장 3절에서 다뤘고, 이 장의 부모-자식 분할이 부모·자식 문서를 만들 때 이 방식을 그대로 재사용한다. 본문 §1.1
벡터 데이터베이스 Vector Database (선행) 임베딩 벡터를 저장하고 유사도 검색을 지원하는 저장소. 2장 4절에서 크로마·파이스를 다뤘고, 이 장은 그 위에 검색 알고리즘·리랭킹을 얹는다. 본문 §1.1·§3.2
청킹(문서 분할) Chunking 긴 문서를 검색·생성에 적합한 작은 단위로 나누는 전처리 과정. 본문 §1
문자 수 기반 분할 Character Splitting 문서를 의미 구조와 무관하게 정해진 문자 길이로 나누는 가장 단순한 분할 방식. 본문 §1
부모-자식 분할 Parent-Child Chunking 문서를 큰 단위의 부모 문서와 작은 단위의 자식 문서로 계층적으로 나누고, 검색은 자식으로 하되 반환은 부모로 하는 분할 방식. 본문 §1.1
질의 변형 Query Reformulation 사용자의 원래 질문을 검색에 더 효율적인 형태로 분석·재구성하는 과정. 본문 §2
다중 질의 생성 Multiquery Generation 원래 질문을 여러 관점의 질문으로 확장해 각각 독립적으로 검색한 뒤 결과를 통합하는 질의 변형 기법. 본문 §2.1
가상 문서 임베딩(HyDE) Hypothetical Document Embeddings 질문 대신 질문에 대한 가상의 답변 문서를 생성해 그것으로 검색하는 질의 변형 기법. 본문 §2.2
검색 알고리즘 Search Algorithm 쿼리와 참조 문서 간의 관련성을 평가해 적합한 문서를 선별하는 로직. 본문 §3
희소 검색 Sparse Retrieval 어휘 사전 크기의 희소 벡터(대부분 0)로 문서·쿼리를 표현해 키워드 일치를 기반으로 검색하는 방식. 본문 §3.1
밀집 검색 Dense Retrieval 문서·쿼리를 저차원의 밀집 벡터(임베딩)로 표현해 의미 유사도로 검색하는 방식. 본문 §3.2
TF-IDF Term Frequency-Inverse Document Frequency 문서 내 등장 빈도(TF)와 전체 문서 집합에서의 희귀성(IDF)을 곱해 단어의 중요도를 계산하는 희소 검색 방식. 본문 §3.1
BM25 Best Matching 25 TF-IDF에 문서 길이 정규화와 단어 빈도 포화 처리를 더해 개선한 희소 검색 알고리즘. 본문 §3.1
형태소 분석 Morphological Analysis 문장을 최소 의미 단위인 형태소로 분리하고 품사를 부여하는 한국어 전처리 과정. BM25의 키워드 매칭 정확도를 높인다. 본문 §3.1
FAISS Facebook AI Similarity Search 대규모 데이터셋에서 고차원 벡터의 유사도 검색을 빠르게 수행하도록 설계된 라이브러리. 본문 §3.2
IVF 인덱스 Inverted File Index 벡터 공간을 여러 클러스터로 나눠, 쿼리와 가까운 클러스터 안에서만 검색하여 속도를 높이는 FAISS 인덱싱 방식. 본문 §3.2
앙상블 검색 Ensemble Retrieval 희소 검색과 밀집 검색의 결과를 가중치로 결합해 각 방식의 단점을 보완하는 검색 방식. 본문 §3.3
문서 후처리(리랭킹) Reranking 1차 검색 결과를 더 정교한 방법으로 재평가해 질문과 무관한 문서를 걸러내고 순위를 다시 매기는 과정. 본문 §4
고성능 LLM 기반 리랭킹 LLM-based Reranking Claude·GPT 같은 고성능 언어 모델에 질의-문서 쌍을 넣어 관련성 점수를 매기고 순위를 재조정하는 리랭킹 방식. 본문 §4.1
크로스 인코더 Cross-Encoder 질문과 문서를 하나로 결합해 인코더 모델에 넣고, 두 텍스트의 관련성을 단일 점수로 직접 산출하는 모델 구조. 본문 §4.2
Bi-인코더 Bi-Encoder 질문과 문서를 각각 독립적으로 인코딩한 뒤 벡터 거리로 유사도를 계산하는 구조. 밀집 검색의 기반이 된다. 본문 §4.2
[CLS] 토큰 [CLS] Token 크로스 인코더 입력의 맨 앞에 오는 특수 토큰으로, 최종 층에서 전체 입력(질문+문서)의 맥락을 요약한 임베딩을 담는다. 본문 §4.2
Self-RAG Self-Reflective RAG LLM이 검색·생성·평가의 각 단계에 특수 토큰을 출력하며 직접 개입해, 검색 필요 여부 판단부터 답변 검증까지 스스로 수행하는 RAG 방식. 본문 §5.1
Retrieve 토큰 Retrieve Token Self-RAG가 외부 문서 검색이 필요한지를 판단해 출력하는 토큰(Yes/No). 본문 §5.1
ISREL 토큰 ISREL Token Self-RAG가 검색된 문서가 질문과 관련 있는지(Relevant/Irrelevant)를 평가하는 토큰. 본문 §5.1
ISSUP 토큰 ISSUP Token Self-RAG가 생성된 답변이 검색된 정보로 얼마나 뒷받침되는지(Fully/Partially/No support)를 평가하는 토큰. 본문 §5.1
ISUSE 토큰 ISUSE Token Self-RAG가 생성된 답변이 질문에 얼마나 유용한지를 1~5점으로 평가하는 토큰. 본문 §5.1
관련성 점수 Relevance Score 리랭커가 질의-문서 쌍에 매기는 값으로, 절대 크기가 아니라 후보 문서 간의 상대적 순위를 정하는 데 쓰인다. 본문 §4
상호 순위 융합(RRF) Reciprocal Rank Fusion 여러 검색기의 순위 목록을 하나로 합칠 때, 각 문서가 각 목록에서 차지한 순위의 역수를 더해 최종 순위를 매기는 방식. 랭체인의 EnsembleRetriever가 내부적으로 이 방식을 쓴다. 본문 §3.3

1. 청킹 전략

RAG 고도화는 관련 문서를 가져오는 Retrieval(검색) 파트와 응답을 생성하는 Generation(생성) 파트 양쪽 모두에서 가능하다. 원문의 표 4-1은 이를 다음과 같이 정리한다.

구분 주요 개선 요소 개략적 개선 방향
Retrieval(문서 검색) 청킹 전략(전처리) 문서를 효과적으로 분할해 검색에 최적화된 형태로 변환 → 검색 품질과 처리 속도 향상
Retrieval(문서 검색) 질의 변형(쿼리 재작성) 질문의 구체화, 가상의 답변 활용 등으로 원래 질문을 검색에 효율적인 형태로 재구성
Retrieval(문서 검색) 검색 알고리즘 최적화 벡터 검색·키워드 검색·하이브리드 검색 등을 활용해 관련성 높은 문서를 더 효율적으로 검색
Retrieval(문서 검색) 문서 후처리(리랭킹) 1차 검색 결과와 질문의 연관성을 재평가해 무관한 문서를 제거 → 더 신뢰할 수 있는 최종 프롬프트 제공
Generation(답변 생성) Self-RAG 모델이 스스로 추가 검색 필요성을 판단하고 부족한 정보를 보완 → 더 깊이 있고 정확한 응답
Generation(답변 생성) 모델 파인튜닝 모델을 학습시켜 검색된 정보를 더 효율적으로 활용하고 맥락에 맞는 응답을 생성하도록 강화(8장에서 다룸)

이 장은 Retrieval 네 가지(청킹 전략·질의 변형·검색 알고리즘·문서 후처리)와 Generation 쪽의 Self-RAG를 순서대로 살펴본다. 모델 파인튜닝 자체는 8장에서 별도로 다룬다.

문서 전처리 단계는 실제 검색에 쓰일 문서를 검색·생성에 최적화된 형태로 가공하는 과정이다. 효과적인 전처리는 검색 정확도를 높이고, 생성 모델이 처리할 컨텍스트의 양을 적절히 조절해 응답 품질을 향상시킨다.

가장 단순한 방식은 문자 수 기반 분할이다. 문서를 일정 길이의 문자 단위로 나누는 방식으로 구현이 간단하고 빠르지만, 문장·단락의 의미적 구조를 고려하지 않아 중요한 정보가 분할 경계에서 잘리거나 문맥이 왜곡될 수 있다.

1.1 부모-자식 분할

2장 3절의 재귀적 문자 텍스트 분할은 문서 구조를 고려하지만 의미를 고려하지 않아 연관된 내용이 분리될 수 있고, 의미 기반 분할은 의미적 연관성은 잘 잡아도 긴 문서의 전체 구조·계층 관계를 완벽히 포착하기는 어렵다. 부모-자식 분할은 이 두 한계를 보완한다 — 가족 관계처럼 원본 문서를 큰 단위의 "부모 문서"로 나누고, 이를 다시 작은 단위의 "자식 문서"로 세분화해 원본 → 부모 → 자식의 3단계 계층을 만든다.

저장·검색은 이원화된다. 자식 문서는 벡터 데이터베이스에 임베딩해 저장하고, 부모 문서는 별도 저장소에 원본 형태로 보관한다. 검색은 자식 문서 기준으로 유사도 검색을 수행하지만, 최종적으로 반환하는 문서는 그 자식이 속한 부모 문서다.

이 방식에는 두 가지 장점이 있다. 첫째는 정확한 정보 검색이다 — 자식 청크는 특정 주제에 집중된 정보를 담아 질문과 정확히 매칭된다. 둘째는 넓은 맥락 제공이다 — 부모 문서를 반환하므로 특정 정보뿐 아니라 그 정보가 속한 전체 맥락까지 함께 파악할 수 있다. 부모-자식 분할이 의미 기반 분할을 대체하는 것은 아니다 — 부모 문서를 자식 문서로 나눌 때 의미 기반 분할을 함께 적용해 두 기술을 상호 보완적으로 쓸 수 있다.

동작 과정은 세 단계다. ① 문서 분할 — 원본을 큰 크기의 부모 문서로 나눈 뒤, 각 부모를 더 작은 자식 문서로 다시 나눈다. ② 메타데이터 할당 — 각 자식 문서에 속한 부모 문서의 식별자를 메타데이터로 붙여 관계를 추적한다. ③ 임베딩 저장 — 자식 문서는 벡터 형태로 임베딩해 벡터 데이터베이스에, 부모 문서는 별도 문서 저장소에 저장한다. 검색 시에는 쿼리와 가장 유사한 자식 문서를 먼저 찾고, 그 메타데이터로 부모 식별자를 확인한 뒤, 문서 저장소에서 해당 부모 문서를 반환한다.

랭체인의 ParentDocumentRetriever로 구현하면 다음과 같다. 부모 문서 크기(chunk_size)는 1000, 자식 문서 크기는 200으로 설정해 계층 구조를 만든다.

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 부모 문서 생성을 위한 텍스트 분할기(큰 단위)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
# 자식 문서 생성을 위한 텍스트 분할기(부모보다 작은 단위)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# 자식 문서를 임베딩해 저장할 벡터 저장소
vectorstore = Chroma(collection_name="split_parents", embedding_function=OpenAIEmbeddings())
# 부모 문서를 원본 그대로 저장할 저장소(RAM 기반 — 종료 시 소실)
store = InMemoryStore()

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)
retriever.add_documents(docs)  # 문서를 넣으면 부모·자식으로 자동 분할되어 각 저장소에 들어간다

print(f"부모 문서 수: {len(list(store.yield_keys()))}")

출력: 부모 문서 수: 219 — 원본 문서가 1000자 단위 부모 219개로 나뉘고, 각 부모가 다시 200자 단위 자식으로 분할되어 벡터 저장소에 담겼다.

"What are the types of investments?"로 검색하면, 자식 문서 검색 결과(vectorstore.similarity_search)는 "forms of investment. These are: (1) safety of principal..."처럼 질문에 정확히 매칭되는 짧은 조각이고, retriever.get_relevant_documents(query)가 반환하는 부모 문서는 같은 내용에 더해 "이 장에서는 부동산 담보대출에 대해 논의할 것"이라는 넓은 맥락까지 포함한다. 자식으로 정확히 찾고 부모로 넓게 보여주는 것이 이 기법의 핵심이다.

2. 질의 변형

기본적인 RAG 시스템은 사용자 질문을 그대로 검색에 쓴다. 하지만 질문이 모호하거나 검색에 최적화되지 않은 형태면 관련 문서를 제대로 찾지 못해 정확도가 떨어진다. 질의 변형은 원래 질문을 분석·재구성해 이 문제를 해결한다 — 더 구체적인 형태로 재작성하거나, 복잡한 질문을 여러 하위 질문으로 분해하거나, 가상의 문서를 생성해 검색을 돕는 등의 기법이 있다.

2.1 다중 질의 생성

다중 질의 생성은 사용자의 원래 질문을 바탕으로 LLM이 여러 관점의 쿼리를 만드는 기법이다. 과정은 세 단계다. ① 다중 질의 생성 — LLM으로 동의어 사용·질문 구조 변경·특정 측면 강조 등을 적용해 원래 질문을 여러 관점의 질문으로 변형한다. ② 병렬 검색 — 생성된 각 쿼리로 독립적으로 문서를 검색한다. ③ 결과 통합 — 각 쿼리의 검색 결과를 종합해 중복을 제거하고 관련성 높은 문서를 선별한다.

예를 들어 "주식 투자를 처음 시작하려면 어떻게 해야 하나요?"라는 질문은 "초보 투자자를 위한 주식 투자 기초지식은 무엇인가?", "주식 시장 분석을 위한 기본적인 재무제표 읽는 법은?", "주식 투자 시작 전 필요한 자금 관리 전략은?" 같은 질문들로 확장된다. 이 방식은 검색 범위를 넓혀 단일 쿼리로는 놓칠 수 있는 정보를 포착하고, 특히 질문이 모호할 때 사용자의 의도를 여러 각도로 해석해 더 적절한 답을 찾는 데 유용하다.

랭체인의 MultiQueryRetriever로 구현한다.

from langchain.retrievers import MultiQueryRetriever
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o", temperature=0.2)  # temperature를 낮춰 일관된 질의 변형 유도
retriever = MultiQueryRetriever.from_llm(
    retriever=vectorstore.as_retriever(),  # 기본 검색기(벡터 데이터베이스)
    llm=llm,
)

question = "주식 투자를 처음 시작하려면 어떻게 해야 하나요?"
unique_docs = retriever.invoke(question)
print(f"결과: {len(unique_docs)}개의 문서가 검색되었습니다.")

로그를 켜면(logging.getLogger("langchain.retrievers.multi_query").setLevel(logging.INFO)) 생성된 세 질문이 그대로 출력되고, 최종적으로 6개 문서가 검색된다. 이렇게 검색된 문서를 RetrievalQA 체인에 넣으면 주식 투자 초보자를 위한 교육·목표 설정 등을 아우르는 종합적인 답변이 만들어진다.

2.2 가상 문서 임베딩(HyDE)

HyDE(Hypothetical Document Embeddings)는 질문이 아니라 질문에 대한 가상의 답변을 만들어 그것으로 검색하는 방식이다. 검색 대상 문서는 질문보다 답변과 의미적으로 더 가까운 경우가 많다는 점을 활용한다.

예를 들어 "주식시장의 변동성이 높을 때 투자 전략은 무엇인가요?"라는 질문 그대로 검색하면 "주식 시장", "변동성", "투자 전략"처럼 일반적이고 광범위한 키워드가 나와 관련성 낮은 문서와도 매칭될 수 있다. 반면 이 질문에 대한 가상의 답변 — "분산 투자, 달러 코스트 애버리징, 안전 자산 비중 확대 등의 전략을 고려할 수 있습니다" — 에는 "분산 투자", "달러 코스트 애버리징", "안전 자산" 같은 구체적이고 전문적인 키워드가 담겨, 실제 금융 서적에 자주 등장하는 표현과 훨씬 잘 맞는다.

HyDE는 원 논문에 따르면 웹 검색·질문 답변·사실 검증 등 11개의 다양한 쿼리 세트에서 기존 방식보다 우수한 성능을 보였고, 구현이 쉬워 기존 RAG 시스템에 쉽게 통합할 수 있다. 다만 가상 문서를 만들기 위해 LLM을 한 번 더 호출해야 해 추가 계산 비용검색 지연 시간(latency) 증가가 단점이다.

랭체인에서는 체인을 순차 연결해 구현한다. ① 가상 문서 생성 체인 — 질문을 받아 지정된 글자 수 언저리의 가상 문서를 생성한다. ② 문서 검색 체인 — 가상 문서로 벡터 DB에서 유사 문서를 찾는다. ③ 최종 응답 생성 체인 — 검색된 문서와 원본 질문으로 최종 답변을 만든다.

from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnableLambda

# 1. 가상 문서 생성 체인
def create_virtual_doc_chain():
    system = "당신은 고도로 숙련된 AI입니다."
    user = """주어진 질문 '{query}'에 대해 직접적으로 답변하는 가상의 문서를 생성하세요.
문서의 크기는 {chunk_size} 글자 언저리여야 합니다."""
    prompt = ChatPromptTemplate.from_messages([("system", system), ("human", user)])
    llm = ChatOpenAI(model="gpt-4o", temperature=0.2)
    return prompt | llm | StrOutputParser()

# 2. 문서 검색 체인 — 가상 문서로 유사 문서를 찾는다
def create_retrieval_chain():
    return RunnableLambda(lambda x: retriever.get_relevant_documents(x["virtual_doc"]))

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# 3. 최종 응답 생성 체인
def create_final_response_chain():
    final_prompt = ChatPromptTemplate.from_template(
        "다음 정보와 질문을 바탕으로 답변해주세요:\n컨텍스트: {context}\n질문: {question}\n답변:"
    )
    final_llm = ChatOpenAI(model="gpt-4o", temperature=0.2)
    return final_prompt | final_llm

참고 — RunnableLambda. 랭체인의 체인은 프롬프트·LLM·파서 같은 랭체인 전용 객체만 연결할 수 있는 것이 원칙이지만, RunnableLambda는 일반 파이썬 함수를 체인에 끼워 넣을 수 있는 실행 가능한(runnable) 객체로 바꿔 준다. 예를 들어 나이를 받아 성인 여부를 반환하는 check_adult(input_dict) 같은 임의의 함수도 RunnableLambda(check_adult)로 감싸면 다른 체인 구성 요소와 똑같이 invoke()로 실행하고 파이프라인에 연결할 수 있다. HyDE 파이프라인의 각 단계(가상 문서 생성·검색·컨텍스트 포매팅·최종 응답)가 함수로 구현된 것도 이 방식 덕분이다.

세 체인과 포맷 유틸리티를 RunnableLambda로 감싸 virtual_doc_step → retrieval_step → context_formatting_step → final_response_step 순으로 이어 붙이면 전체 파이프라인이 완성된다. pipeline.invoke({"question": question})로 실행하면, "주식 시장의 변동성이 높을 때 투자 전략은 무엇인가요?"라는 질문에 대해 먼저 가상 답변이 생성되고, 그 가상 답변으로 관련 문서를 찾은 뒤, 그 문서를 컨텍스트로 최종 답변을 만드는 흐름을 단계마다 로그로 확인할 수 있다.

3. 검색 알고리즘

검색 알고리즘은 쿼리와 참조 문서 간 관련성을 평가해 적합한 문서를 선별하는 로직이다. 크게 희소 검색밀집 검색 두 가지로 나뉜다.

3.1 희소 검색

희소 검색은 문서와 쿼리를 어휘 사전 크기의 희소 벡터(대부분 0, 등장 단어 위치만 값을 가짐)로 표현해 키워드 일치를 기반으로 관련성을 평가하는 방식이다. 구현이 간단하고 계산 효율성이 높지만 단어의 의미적 관계·문맥은 포착하지 못한다. 동작은 다섯 단계다 — ① 문서 변환(희소 벡터화, 한국어는 불용어 제거·어간 추출 전처리 병행) ② 질문 처리(동일 방식으로 벡터화) ③ 유사도 계산(주로 코사인 유사도) ④ 랭킹 ⑤ 상위 N개 반환.

TF-IDF는 단어의 문서 내 등장 빈도(TF)와 전체 문서 집합에서의 희귀성(IDF)을 곱해 중요도를 계산한다. 문서 내에서 자주 등장하면서도 전체 집합에서는 드문 단어에 높은 점수를 줘 불용어의 영향을 줄이지만, 문서 길이가 길수록 단어 빈도가 자연히 높아지는 편향과, 빈도가 늘수록 점수가 선형으로 계속 커지는 한계가 있다.

BM25는 이 두 한계를 보완한다. 문서 길이를 정규화해 긴 문서의 편향을 줄이고, 단어 빈도가 늘수록 점수 증가율이 감소하도록(포화 처리) 설계해 과도한 반복 단어의 영향을 제한한다.

참고 — BM25 점수 계산식. 쿼리 Q의 각 단어 q에 대해 문서 D의 점수를 다음과 같이 합산한다.

Score(D, Q) = Σ IDF(q) × [ f(q, D) × (k1 + 1) ] / [ f(q, D) + k1 × (1 - b + b × |D| / avgdl) ]

f(q, D)는 문서 D에서 단어 q의 등장 빈도, |D|는 문서 길이, avgdl은 전체 문서 집합의 평균 길이, k1(보통 1.2~2.0)과 b(보통 0.75)는 조정 매개변수다. TF-IDF와 달리 분모에 (1 - b + b × |D| / avgdl) 항을 더해 문서가 평균보다 길수록 점수를 깎아 길이 편향을 줄이고, 분자·분모 모두에 있는 f(q, D)가 커질수록 전체 값이 k1 + 1에 가까워지도록(포화) 설계해 한 단어가 아무리 반복돼도 점수가 무한정 커지지 않게 한다. 이런 정교함 덕분에 BM25는 희소 벡터 기반 검색의 기본 랭킹 알고리즘으로 널리 쓰인다.

참고 — 희소 벡터의 예. 어휘 사전이 500,000개 단어로 구성된 금융 뉴스 검색 시스템에서 "Tesla stock surges as electric vehicle demand rises"라는 헤드라인은, 그 문장에 등장한 8개 단어(Tesla·stock·surges·as·electric·vehicle·demand·rises)의 위치만 1이고 나머지 499,992개 위치는 전부 0인 벡터로 표현된다. 벡터 대부분이 0으로 채워져 "듬성듬성하다"는 뜻에서 희소 벡터라 부른다.

참고 — 한국어 BM25는 형태소 전처리가 성능을 좌우한다. 한국어는 교착어라 같은 의미의 단어도 활용형이 매우 다양하다. 형태소 분석(예: "먹었다" → 먹/VV + 었/EP + 다/EF)으로 명사·동사 등 핵심 품사만 추출하고, 복합명사를 구성 요소로 분리하고, 활용형을 기본형(표제어)으로 통일하면 조사·어미 같은 노이즈가 줄고 쿼리-문서 매칭 정확도가 크게 오른다.

잘못된 예 — 형태소 전처리 없이 BM25. 원문 그대로 공백 단위로만 토큰화하면 "먹었습니다"와 "먹다"가 다른 단어로 취급돼, 질문에 "먹다"가 있어도 "먹었습니다"가 포함된 문서를 놓칠 수 있다.

올바른 예 — Kiwi 형태소 분석기로 전처리.

from langchain_community.retrievers import BM25Retriever
from kiwipiepy import Kiwi

kiwi_tokenizer = Kiwi()

def kiwi_tokenize(text):
    # 텍스트를 형태소 단위로 쪼개고 기본형만 남긴다 — 활용형 차이를 흡수한다
    return [token.form for token in kiwi_tokenizer.tokenize(text)]

bm25_retriever = BM25Retriever.from_documents(docs, preprocess_func=kiwi_tokenize)
bm25_retriever.k = 2  # 상위 2개 문서만 반환

qa_chain = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(temperature=0.2, model="gpt-4o"),
    chain_type="stuff",
    retriever=bm25_retriever,
    return_source_documents=True,
)
qa_chain.invoke("이 회사가 발행한 주식의 총 발행량이 어느 정도야?")

투자설명서 PDF를 대상으로 실행하면 "이 회사가 발행한 주식의 총 발행량은 13,602,977주입니다"라는 정확한 답변이 나온다.

3.2 밀집 검색

밀집 검색은 문서와 쿼리를 트랜스포머 기반 임베딩 모델로 고차원 밀집 벡터(예: 300차원)로 변환해 의미 공간에서 비교하는 방식이다. 단어 존재 여부가 아니라 의미·문맥을 고려하므로 키워드가 정확히 일치하지 않아도 의미적으로 연관된 문서를 포착한다. 다만 계산 비용이 크고 도메인에 따라 임베딩 모델의 파인튜닝이 필요할 수 있다. 동작 과정은 희소 검색과 같은 5단계(변환→처리→유사도→랭킹→반환)를 밟되, 벡터화에 신경망 기반 임베딩 모델을 쓴다는 점이 다르다.

참고 — 밀집 벡터의 예. 같은 헤드라인을 밀집 벡터로 표현하면 300차원을 가정할 때 [0.25, -0.10, 0.45, ..., 0.05]처럼 모든 차원이 실수값을 갖는 벡터가 된다. 각 차원이 개별적으로 특정 의미를 갖지는 않지만, 모든 차원이 함께 결합돼 텍스트 전체의 의미를 고차원 공간에 나타낸다. 이렇게 정량화된 벡터는 다른 헤드라인과의 거리 계산(의미적 유사성 비교)이나 클러스터링·분류 같은 기계학습 작업에 바로 활용할 수 있다.

수백만 개 문서를 매번 전수 비교하면 시간이 급격히 늘어난다. FAISS는 이를 해결하는 대표적 라이브러리다. 가장 단순한 Flat 인덱스는 모든 벡터를 그대로 저장해 전체 검색을 수행하지만, 데이터가 많아지면 느려진다. IVF(Inverted File) 인덱스는 벡터 공간을 여러 클러스터로 나누고, 검색 시 쿼리와 가장 가까운 클러스터만 살펴봐 속도를 크게 높인다 — 도서관에서 책을 주제별로 분류해 두면 관련 서가만 찾아가면 되는 것과 같은 원리다. 예를 들어 100만 개 벡터를 1,000개 클러스터로 나누면, 쿼리는 가장 가까운 클러스터의 약 1,000개 벡터하고만 비교하면 되어 전수 비교보다 훨씬 빠르다.

from langchain_openai.embeddings import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

embedding = OpenAIEmbeddings(model="text-embedding-3-large")
faiss_store = FAISS.from_documents(docs, embedding)
faiss_store.save_local("/content/DB")

vectordb = FAISS.load_local("/content/DB", embeddings=embedding, allow_dangerous_deserialization=True)
faiss_retriever = vectordb.as_retriever(search_kwargs={"k": 2})  # 상위 2개 문서 반환

qa_chain = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(temperature=0.2, model="gpt-4o"),
    chain_type="stuff",
    retriever=faiss_retriever,
    return_source_documents=True,
)

같은 투자설명서 질문("총 발행량이 어느 정도야?")에 희소 검색과 마찬가지로 "보통주 13,602,977주"라는 정확한 답변을 낸다 — 이 문서에서는 회사명·숫자 같은 고유 표현이 뚜렷해 희소·밀집 두 방식 모두 잘 통했다.

3.3 앙상블 검색

희소 검색은 키워드 일치는 빠르고 정확하지만 문맥·의미를 놓치고, 밀집 검색은 의미는 잘 잡지만 계산 비용이 높고 특정 표현을 정확히 포착하지 못할 수 있다. 앙상블 검색은 두 방식을 결합해 서로의 단점을 보완한다. 예를 들어 "주식 시장의 변동성 분석"이라는 쿼리에서 희소 검색은 "주식", "시장", "변동성" 같은 키워드가 정확히 포함된 문서를, 밀집 검색은 "증권 시장의 불안정성 연구"처럼 키워드는 다르지만 의미적으로 관련된 문서를 함께 찾아낸다.

희소·밀집의 비중은 상황에 맞게 조절한다 — 기술·법률 문서처럼 정확한 용어 매칭이 중요하면 희소 검색 비중을, 일반 질문 답변이나 의미 기반 추천처럼 폭넓은 이해가 중요하면 밀집 검색 비중을 높인다.

from langchain.retrievers import EnsembleRetriever

bm25_retriever.k = 4
faiss_retriever = vectordb.as_retriever(search_kwargs={"k": 4})

# 희소:밀집 = 1:1 — 키워드 매칭이 중요한 도메인이면 weights를 [0.7, 0.3]처럼 희소 쪽으로 기울일 수 있다
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, faiss_retriever],
    weights=[0.5, 0.5],
)

qa_chain = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(temperature=0.2, model="gpt-4o"),
    chain_type="stuff",
    retriever=ensemble_retriever,
    return_source_documents=True,
)

같은 질문으로 실행하면 희소·밀집 검색 때와 동일하게 정확한 발행량을 답한다 — 이 예제에서는 두 방식이 이미 같은 답에 수렴했지만, 각 방식이 서로 다른 문서를 상위에 올리는 경우일수록 앙상블 검색의 결합 효과가 더 뚜렷하게 드러난다.

4. 문서 후처리

초기 검색 알고리즘(희소·밀집·앙상블)은 대규모 문서 집합에서 빠르게 후보를 좁히는 데는 효과적이지만, 관련성 평가 자체는 기계적인 방식(키워드 매칭·근사 벡터 유사도)에 의존해 완벽하지 않다. 문서 후처리(리랭킹)는 이 1차 검색 결과를 더 정교한 방법으로 재평가해 순위를 다시 매기는 과정이다. 초기 검색으로 빠르게 후보를 좁힌 뒤, 리랭커가 그 후보를 정밀하게 평가하는 2단계 전략은 도서관에서 주제별 서가를 먼저 찾고(초기 검색), 그 서가의 책을 전문가가 직접 검토해(리랭킹) 가장 적합한 책을 고르는 과정에 비유할 수 있다.

리랭커는 후보 문서 각각에 관련성 점수를 매기고, 그 점수의 절대 크기가 아니라 후보 간 상대적 순위를 기준으로 상위 K개만 최종 선택한다.

참고 — 책 찾기로 비유한 리랭킹. '주식 시장 투자 전략'을 주제로 초기 검색(희소·밀집)을 하면 키워드 매칭(희소)으로 『현대 주식 투자전략』·『주식 시장 역사』가, 의미 매칭(밀집)으로 『증권 분석 방법』·『금융 시장 예측 기법』이 함께 걸려든다 — 속도는 빠르지만 관련성이 떨어지는 책(『주식 시장 역사』)도 섞인다. 리랭커는 목차·서문·주요 내용을 바탕으로 각 책과 주제의 관련성을 다시 채점한다(예: 『현대 주식 투자전략』 3.5점, 『증권 분석 방법』 2.8점, 『금융 시장 예측 기법』 2.1점, 『주식 시장 역사』 1.2점). 이 점수 순으로 상위 2권만 남기면 『현대 주식 투자전략』·『증권 분석 방법』만 최종 선별된다 — 도서관 전체를 처음부터 살펴보는 것보다 훨씬 효율적이면서, 단순 키워드 매칭보다 정확한 결과다.

4.1 고성능 대규모 언어 모델 기반 리랭킹

Claude나 GPT 같은 고성능 언어 모델로 초기 검색 결과를 재평가하는 방식이다. 절차는 세 단계다 — ① 질의-문서 쌍의 관련성을 평가하는 프롬프트 설계 ② 각 문서에 대해 LLM을 호출해 관련성 점수 획득 ③ 점수 기준 순위 재조정. 실제로 챗GPT-4를 리랭킹에 쓴 연구는 TREC·BEIR 같은 평가 데이터셋에서 BM25·monoBERT·monoT5·Cohere Rerank 같은 기존 방식들을 앞서는 성능을 보였다(Sun et al., 2023). 광범위하게 사전 학습된 LLM 특성상 추가 학습 없이 다양한 도메인·질의 유형에 적용할 수 있고, 필요하면 평가 근거를 자연어로 함께 제시할 수 있어 설명 가능성(Explainable AI) 측면에서도 유리하다. 반면 많은 계산 자원이 필요해 비용이 높고 처리 속도가 느리다는 단점이 있다.

from pydantic import BaseModel, Field
from langchain import PromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from typing import List

class RelevanceScore(BaseModel):
    relevance_score: float = Field(description="문서가 쿼리와 얼마나 관련이 있는지를 나타내는 점수.")

def reranking_documents(query: str, docs: List, top_n: int = 2):
    parser = JsonOutputParser(pydantic_object=RelevanceScore)
    prompt = PromptTemplate(
        template="""1점부터 10점까지 점수를 매겨, 다음 문서가 질문과 얼마나 관련이 있는지 평가해주세요.
단순히 키워드가 일치하는 것이 아니라 쿼리의 구체적인 맥락과 의도를 고려하세요.
{format_instructions}
question: {query}
document: {doc}
relevance_score:""",
        input_variables=["query", "doc"],
        partial_variables={"format_instructions": parser.get_format_instructions()},
    )
    llm = ChatOpenAI(temperature=0, model_name="gpt-4o", max_tokens=3000)
    chain = prompt | llm | parser

    scored_docs = []
    for doc in docs:
        try:
            score = float(chain.invoke({"query": query, "doc": doc.page_content})["relevance_score"])
        except Exception as e:
            # 파싱 실패 시 대상을 통째로 버리지 않고 중간값(5점)으로 처리 — 한 문서의 오류가 전체를 막지 않게
            print(f"오류 발생: {e}. 기본 점수 5점을 사용합니다.")
            score = 5
        scored_docs.append((doc, score))

    reranked = sorted(scored_docs, key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in reranked[:top_n]]

"이 회사의 2022년 영업손실이 정확히 얼마야?"라는 지엽적인 질문으로 밀집 검색 상위 4개 문서를 뽑은 뒤 리랭킹하면, 2022년 영업손실을 정확히 언급한 2개 문서만 남는다. 이를 CustomRetriever로 감싸 RetrievalQA에 연결하면 "이 회사의 2022년 영업손실은 149.1억원입니다"라는 정확한 답을 얻는다.

4.2 크로스 인코더 기반 리랭킹

크로스 인코더 기반 리랭킹은 BERT 계열 인코더 모델로 질문과 문서의 관련성을 평가한다. 질문과 문서를 [CLS] 질문 [SEP] 문서 [SEP] 형태로 결합해 인코더에 넣고, 최종 층의 [CLS] 토큰 임베딩(전체 입력의 맥락 요약)을 선형 층에 통과시켜 관련성 점수를 산출한다.

참고 — Bi-Encoder vs Cross-Encoder. 밀집 검색에 쓰이는 Bi-Encoder는 질문과 문서를 독립적으로 인코딩한 뒤 코사인 유사도 같은 거리 계산으로 유사성을 판단한다. Cross-Encoder는 질문과 문서를 함께 인코더에 넣어 관련성 점수를 직접 얻는다. 문장 100,000개가 있을 때 Bi-Encoder는 100,000번의 인코딩만 필요하지만, Cross-Encoder는 가능한 모든 쌍(약 50억 개)을 인코딩해야 해 대규모에서 훨씬 느리다. 반면 두 텍스트의 관계를 직접 모델링하는 만큼 정확도는 대체로 Cross-Encoder가 높다. 실무에서는 Bi-Encoder로 대규모 후보군을 빠르게 추린 뒤 Cross-Encoder로 재평가하는 조합이 흔하다 — 속도와 정확도의 균형을 맞추는 방식이다. (출처: osanseviero, Sentence Embeddings 시리즈)

크로스 인코더는 LLM 기반 리랭커보다 낮은 사양 모델을 쓰므로 계산 자원 요구가 낮고, 비교적 저렴한 비용으로 특정 도메인 데이터에 파인튜닝해 성능을 더 끌어올릴 수 있다. 다만 상용 LLM보다 사전 학습된 지식 범위가 좁아 추가 학습이 필요한 경우가 많고, 평가 근거를 자연어로 제공하지 못한다.

from sentence_transformers import CrossEncoder
from langchain_core.retrievers import BaseRetriever
from pydantic import Field
from typing import Any, List

# Microsoft의 MS MARCO 데이터셋으로 학습된 크로스 인코더
crossencoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")

class RetrieverWithCrossEncoder(BaseRetriever):
    vectorstore: Any = Field(description="초기 검색을 위한 벡터 저장소")
    crossencoder: Any = Field(description="재순위화를 위한 크로스 인코더 모델")
    k: int = Field(default=5, description="초기에 검색할 문서 수")
    rerank_top_k: int = Field(default=2, description="재순위화 후 최종 반환할 문서 수")

    class Config:
        arbitrary_types_allowed = True

    def get_relevant_documents(self, query: str) -> List:
        initial_docs = self.vectorstore.similarity_search(query, k=self.k)  # ① 초기 검색으로 후보 좁히기
        pairs = [[query, doc.page_content] for doc in initial_docs]         # ② 질문-문서 쌍 준비
        scores = self.crossencoder.predict(pairs)                          # ③ 관련성 점수 계산
        scored_docs = sorted(zip(initial_docs, scores), key=lambda x: x[1], reverse=True)  # ④ 정렬
        return [doc for doc, _ in scored_docs[: self.rerank_top_k]]        # ⑤ 상위 K개만 반환

같은 "2022년 영업손실" 질문에 적용하면, 리랭킹된 2개 문서 중 하나만 2022년을 정확히 다루고 다른 하나는 2023년 영업손실 문서였다 — 앞선 LLM 기반 리랭킹은 두 문서 모두 2022년을 정확히 골랐던 것과 대비된다. 잘못된 예는 이 크로스 인코더 결과를 그대로 최종 답변에 쓰는 것이다 — 실제로 "이 회사의 2022년 영업손실은 149억원입니다"라는 얼추 맞는 답을 얻긴 했지만, 근거 문서 중 하나가 다른 연도라는 것을 놓치면 다른 질문에서는 틀린 연도의 문서로 답할 위험이 있다. 올바른 예는 정확도가 중요한 질의에는 크로스 인코더 결과를 그대로 신뢰하지 말고 LLM 기반 리랭킹이나 더 큰 k값·재검증 단계를 함께 두는 것이다. 이는 크로스 인코더가 속도는 빠르지만 LLM 기반 방식보다 정확도가 낮을 수 있음을 보여주는 실측 사례이며, 리랭킹 방식은 작업의 속도·정확도 요구에 따라 선택해야 한다. 정확도가 응답 지연보다 중요한 질의(예: 재무·법률 사실 확인)에는 LLM 기반을, 대량의 질의를 실시간으로 처리해야 하는 서비스(예: 고객 응대 챗봇)에는 크로스 인코더 기반을 우선 고려하는 식으로, "무엇이 틀렸을 때 더 비싼가"를 기준으로 선택하는 것이 실무적으로 합리적이다.

5. 확장된 RAG 방법론

5.1 Self-RAG 개요

앞서 다룬 청킹·질의 변형·검색 알고리즘·리랭킹으로 RAG 성능을 크게 끌어올릴 수 있지만, 전통적인 RAG는 여전히 구조적 한계가 있다 — 질문이 문서와 무관해도 정해진 수의 문서를 무작위로 검색해 프롬프트에 넣는 방식은 부정확한 응답을 낳을 수 있고, 검색된 정보를 효과적으로 활용하지 못하거나 원래 질문의 맥락을 잃기도 한다.

Self-RAG(Asai et al., 2023)는 LLM이 RAG의 각 단계에 특수 토큰을 출력해 직접 개입하는 방식이다. 검색(Retrieve) — 먼저 외부 데이터 검색이 필요한지 판단해 Retrieve=Yes/No 토큰을 출력한다. 생성(Generate) — 검색이 필요했다면, 검색된 각 문서의 관련성을 ISREL=Relevant/Irrelevant로 평가하고, 관련 문서가 있으면 그것으로, 없으면 모델의 기존 지식으로 답변을 생성한다. 평가(Critique) — 생성된 답변이 검색 정보로 얼마나 뒷받침되는지를 ISSUP=Fully supported/Partially supported/No support로, 답변이 질문에 얼마나 유용한지를 ISUSE=1(최저)~5(최고)로 평가한다.

예를 들어 "테슬라 주식이 향후 1년 동안 좋은 투자처가 될까요?"라는 질문에는 최신 정보가 필요하므로 Retrieve=Yes가 나오고 관련 정보를 검색한다. 테슬라의 최근 재무 보고서는 Relevant로, 무관한 일반 주식 시장 정보는 Irrelevant로 평가된 뒤, 관련 문서를 바탕으로 답변을 생성하고 ISSUP·ISUSE로 그 답변의 근거와 유용성을 다시 점검한다. 이 자체 반영(Self-Reflection) 과정이 Self-RAG의 핵심이며, 연구 결과 Self-RAG 7B 모델은 PopQA 데이터셋에서 54.9%의 정확도로 챗GPT(29.3%)·Llama2-chat 13B(20.0%)를 크게 앞섰다.

5.2 Self-RAG 구현

Self-RAG는 원래 오픈소스 모델을 직접 학습시켜야 하지만 상당한 자원이 필요하다. 여기서는 상용 LLM(GPT)으로 Self-RAG의 메커니즘을 시뮬레이션해 핵심 원리를 실습한다 — 원래 시스템과 성능 차이가 있을 수 있다는 점은 감안해야 한다.

검색 필요 여부·관련성 평가·답변 생성·지원 평가·유용성 평가, 다섯 파이프라인은 모두 같은 패턴(출력 형식 클래스 + 프롬프트 템플릿 + LLM)으로 만든다.

from pydantic import BaseModel, Field
from langchain import PromptTemplate
from typing import Literal

# ① 검색 필요 여부 판단
class RetrievalResponse(BaseModel):
    Reasoning: str = Field(description="검색의 필요 여부를 추론하는 과정(2~3문장 이내)")
    Retrieve: Literal["Yes", "No"] = Field(description="검색 필요 여부")

retrieval_prompt = PromptTemplate(
    input_variables=["query"],
    template="""주어진 질문에 대해, 외부 문서를 참고하는 것이 더 나은 응답을 생성하는 데 도움이 되는지 판단해주세요.
추론 과정을 작성한 뒤 "Yes" 또는 "No"로 답하세요.
질문: {query}""",
)
llm = ChatOpenAI(model="gpt-4o", max_tokens=2000, temperature=0.2)
retrieval_chain = retrieval_prompt | llm.with_structured_output(RetrievalResponse)

# ② 관련성 평가 — 고성능 LLM 기반 리랭킹(§4.1)과 같은 발상을 구조화된 출력으로 구현한 것
class RelevanceResponse(BaseModel):
    Reasoning: str = Field(description="연관 문서의 관련성 평가 추론 과정")
    ISREL: Literal["Relevant", "Irrelevant"] = Field(description="관련성 평가 결과")

# ③ 답변 생성
class GenerationResponse(BaseModel):
    response: str = Field(description="질문과 연관 문서를 바탕으로 생성된 답변")

# ④ 지원 평가 — 답변이 검색 문서에 얼마나 근거하는지
class SupportResponse(BaseModel):
    Reasoning: str = Field(description="답변이 연관 문서에 충분히 근거하는지 추론 과정")
    ISSUP: Literal["Fully supported", "Partially supported", "No support"] = Field(description="근거 평가 결과")

# ⑤ 유용성 평가 — 답변이 질문에 얼마나 도움이 되는지 1~5점
class UtilityResponse(BaseModel):
    Reasoning: str = Field(description="응답의 유용성 평가 추론 과정")
    ISUSE: Literal[1, 2, 3, 4, 5] = Field(description="응답의 유용성 평가 결과")

이 다섯 체인을 SelfRAG 클래스로 통합해 전체 워크플로우를 조율한다. 핵심은 process_query 메서드다.

class SelfRAG:
    def __init__(self, vectorstore, retrieval_chain, relevance_chain, generation_chain, support_chain, utility_chain, top_k):
        self.vectorstore = vectorstore
        self.retrieval_chain = retrieval_chain
        self.relevance_chain = relevance_chain
        self.generation_chain = generation_chain
        self.support_chain = support_chain
        self.utility_chain = utility_chain
        self.top_k = top_k

    def determine_retrieval(self, query):
        # 1단계: 검색이 필요한지 LLM이 스스로 판단
        return self.retrieval_chain.invoke({"query": query}).Retrieve

    def retrieve_documents(self, query):
        # 2단계: 벡터 저장소에서 후보 문서를 가져온다(이 장 §3.2 밀집 검색과 동일한 원리)
        docs = self.vectorstore.similarity_search(query, k=self.top_k)
        return [doc.page_content for doc in docs]

    def evaluate_relevance(self, query, contexts):
        # 3단계: 각 문서를 ISREL로 평가해 관련 있는 것만 남긴다
        relevant = []
        for context in contexts:
            r = self.relevance_chain.invoke({"query": query, "context": context})
            if r.ISREL == "Relevant":
                relevant.append(context)
        return relevant

    def generate_responses(self, query, relevant_contexts):
        # 4단계: 관련 문서마다 별도로 답변 후보를 생성한다
        return [self.generation_chain.invoke({"query": query, "context": c}).response for c in relevant_contexts]

    def generate_without_retrieval(self, query):
        # 검색이 불필요하거나 관련 문서가 없을 때 — 모델의 기존 지식으로만 답한다
        return self.generation_chain.invoke({"query": query, "context": "관련된 컨텍스트를 찾지 못했습니다."}).response

    def assess_and_evaluate(self, query, responses, relevant_contexts):
        # 5·6단계: 답변마다 ISSUP(근거)·ISUSE(유용성)를 매긴다
        assessed = []
        for response, context in zip(responses, relevant_contexts):
            support = self.support_chain.invoke({"query": query, "response": response, "context": context})
            utility = self.utility_chain.invoke({"query": query, "response": response})
            assessed.append((response, support.ISSUP, int(utility.ISUSE)))
        return assessed

    def select_best_response(self, responses):
        # 근거 등급(Fully > Partially > 그 외) 우선, 그 안에서는 유용성 점수가 높은 것을 선택
        fully = [r for r in responses if r[1] == "Fully supported"]
        if fully:
            return max(fully, key=lambda x: x[2])
        partially = [r for r in responses if r[1] == "Partially supported"]
        if partially:
            return max(partially, key=lambda x: x[2])
        return max(responses, key=lambda x: x[2])

    def process_query(self, query):
        if self.determine_retrieval(query) == "Yes":
            contexts = self.retrieve_documents(query)
            relevant_contexts = self.evaluate_relevance(query, contexts)
            if not relevant_contexts:
                return self.generate_without_retrieval(query)  # 관련 문서가 없으면 검색 없이 생성
            responses = self.generate_responses(query, relevant_contexts)
            assessed = self.assess_and_evaluate(query, responses, relevant_contexts)
            return self.select_best_response(assessed)[0]
        return self.generate_without_retrieval(query)  # 애초에 검색이 불필요하다고 판단된 경우

"이 회사의 바이오 의약품 라이센스 아웃 수익을 알려줘"라는 질문으로 실행하면, 먼저 검색이 필요하다고 판단(Retrieve=Yes)하고 4개 문서를 검색한 뒤, 관련성 평가에서 2개만 Relevant로 남는다(나머지 2개는 계약 해지·다른 계약처럼 수익 정보가 없어 Irrelevant). 두 관련 문서로 각각 답변을 생성하면 둘 다 Fully supported·유용성 5점을 받고, 그중 하나가 최종 응답으로 선택된다 — 계약 상대방·계약 금액·로열티 구조를 구체적으로 담은 답변이다.

모든 평가와 필요한 개선 과정을 거친 최종 출력에는 세 요소가 함께 담길 수 있다 — ① 생성된 텍스트 답변 ② 답변이 사용한 정보 출처에 대한 인용 ③ 각 세그먼트의 평가 결과(ISREL·ISSUP·ISUSE 토큰 값). 답변 자체뿐 아니라 그 답변이 어떤 근거로, 어떤 평가를 거쳐 나왔는지까지 함께 제공한다는 점이 일반 RAG의 단순 텍스트 출력과 다르다.

Self-RAG는 응답의 정확성·신뢰성을 높이지만, 여러 단계에서 LLM을 반복 호출하므로 처리 시간과 비용이 늘어난다. 도입 여부는 사용 목적과 시스템 환경(정확도가 중요한가, 응답 속도가 중요한가)을 고려해 판단해야 한다.

핵심 개념 정리

개념 한 줄 설명
부모-자식 분할 검색은 작은 자식 청크로, 반환은 넓은 맥락의 부모 청크로 — 정확성과 맥락을 동시에 확보
다중 질의 생성 원 질문을 여러 관점으로 확장해 병렬 검색 후 통합 — 모호한 질문에 특히 유용
HyDE 질문이 아니라 질문의 가상 답변으로 검색 — 답변이 문서와 의미적으로 더 가깝다는 점을 활용
희소 검색(BM25) 키워드 일치 기반, 빠르고 가볍지만 의미·문맥을 못 본다
밀집 검색(FAISS) 임베딩 유사도 기반, 문맥은 보지만 계산이 무겁다 — IVF 인덱스로 속도를 보완
앙상블 검색 희소+밀집을 가중치로 결합 — 도메인 특성에 따라 비중 조절
LLM 기반 리랭킹 고성능 LLM이 관련성 점수를 매김 — 정확도는 높지만 비용·지연이 크다
크로스 인코더 기반 리랭킹 질문+문서를 함께 인코딩해 관련성 점수를 직접 산출 — 빠르지만 LLM보다 정확도가 낮을 수 있다
Self-RAG Retrieve·ISREL·ISSUP·ISUSE 네 토큰으로 검색·생성·평가를 모델이 스스로 제어
이 장 전략들의 공통 목적 RAG의 Retrieval(검색)과 Generation(생성) 양쪽 정확도를 함께 끌어올린다

실무 체크리스트

  • [ ] 문서에 계층 구조(장·절)가 있다면, 단순 청킹 대신 부모-자식 분할로 정확성과 맥락을 동시에 잡을 수 있는지 검토했는가?
  • [ ] 사용자 질문이 모호하거나 짧은 경우가 많다면 다중 질의 생성이나 HyDE 같은 질의 변형을 검토했는가?
  • [ ] 검색 정확도가 떨어질 때, 원인이 질문 자체의 모호함인지 검색 알고리즘의 한계인지 구분했는가?
  • [ ] 도메인이 전문 용어·고유명사 중심(법률·기술 문서)인지, 일반 자연어 중심인지에 따라 희소·밀집 검색의 비중을 조절했는가?
  • [ ] 앙상블 검색의 가중치를 임의로 1:1로 고정하지 않고 실제 질의 로그로 검증했는가?
  • [ ] 리랭킹을 붙이기 전에, 정말 초기 검색 결과에 관련 없는 문서가 섞여 있는지 확인했는가?
  • [ ] 리랭킹 방식을 고를 때 정확도(LLM 기반)와 속도·비용(크로스 인코더 기반) 중 무엇이 더 중요한 요구인지 판단했는가?
  • [ ] 크로스 인코더 리랭킹 결과를 최종 답변에 그대로 신뢰하기 전에, 정확도가 중요한 질의에서는 재검증 단계를 두었는가?
  • [ ] Self-RAG처럼 여러 단계 LLM 호출이 필요한 방법을 도입하기 전에, 늘어나는 지연·비용이 요구사항에 맞는지 확인했는가?
  • [ ] 이 장의 고도화 기법들을 도입한 뒤, 도입 전과 비교해 실제로 검색·응답 품질이 개선됐는지 평가 지표로 측정했는가?
  • [ ] 여러 고도화 기법(질의 변형+리랭킹 등)을 동시에 도입할 때, 각 기법이 늘리는 지연·비용을 합산해 전체 응답 시간이 서비스 요구치를 넘지 않는지 확인했는가?

연습문제

  1. 설계. 법률 계약서처럼 조항 번호까지 정확히 찾아야 하는 검색 시스템을 만든다면, 부모-자식 분할에서 부모 문서와 자식 문서의 크기를 각각 어떻게 잡을지, 그 이유를 §1.1의 원리로 설명하라.
  2. 판단. 사용자 질문이 "이거 어떻게 해요?"처럼 매우 모호할 때, 다중 질의 생성과 HyDE 중 어느 쪽이 더 적합한지 §2.1·§2.2의 차이를 근거로 논하라.
  3. 설계. 전문 용어가 많은 기술 문서를 검색하는 시스템에서 희소 검색과 밀집 검색의 비중을 어떻게 조절할지, §3.3의 원리로 설명하라.
  4. 판단. 응답 속도가 중요한 실시간 채팅 서비스에 리랭킹을 추가해야 한다면 LLM 기반과 크로스 인코더 기반 중 무엇을 선택할지, §4.1·§4.2의 트레이드오프로 근거를 들어라.
  5. 적용. Self-RAG의 관련성 평가(ISREL) 단계에서 검색된 모든 문서가 Irrelevant로 판정됐다면 시스템은 어떻게 동작해야 하는지, §5.2의 어느 메서드가 이를 처리하는지 설명하라.

최신 동향 (2026-09 기준)

최신 동향 (검증 2026-09-12, Tavily+Brave — SearXNG는 각 질의에서 주제와 무관한 결과만 반환해 미반영, 실질 N=2) — 이 장이 다룬 원리(부모-자식 분할·질의 변형·희소/밀집/앙상블 검색·리랭킹·Self-RAG의 자체 반영 메커니즘)는 그대로 유효하다. 다만 실습 코드가 쓰는 API 하나와, Self-RAG 이후 등장한 확장 기법 두 가지가 책 집필 이후 갱신됐다.

  • retriever.get_relevant_documents()는 구버전 API다. 이 장의 모든 실습 코드가 이 메서드로 문서를 가져오지만, LangChain은 langchain-core 0.1.46부터 이를 지원 중단(deprecated)하고 retriever.invoke(query)(비동기는 ainvoke)를 쓰도록 안내한다. 지원 중단된 메서드는 langchain-core 1.0에서 제거될 예정이다. 새 코드를 작성할 때는 retriever.get_relevant_documents(query) 대신 retriever.invoke(query)로 바꿔 쓴다. (LangChain 공식 API 문서 — BaseRetriever)
  • 부모-자식 분할이 푸는 문제(맥락 손실)를 다른 각도로 접근하는 Contextual Retrieval이 등장했다. Anthropic은 청크를 임베딩·BM25 인덱싱하기 전에 "이 청크가 전체 문서에서 어떤 맥락에 있는지"를 LLM으로 요약해 청크 앞에 붙이는 방식을 공개했다. 부모-자식 분할이 검색 후 상위 문서를 반환해 맥락을 보강한다면, 이 기법은 청크 자체에 맥락을 미리 심어 둔다는 점이 다르다. 공개된 벤치마크에서 상위 20개 청크 검색 실패율을 기존 대비 35%, 리랭킹까지 결합하면 49% 낮췄다고 보고됐다. (Anthropic 공식 — Introducing Contextual Retrieval, Claude Cookbook 실습 가이드)
  • Self-RAG 이후 Corrective RAG(CRAG)·Adaptive-RAG가 학계에 나왔다. 이 장의 Self-RAG는 모델 스스로 검색·생성·평가를 반성하는 방식인데, 뒤이어 나온 Corrective RAG(CRAG)는 검색 품질 자체를 채점해 잘못된 검색에 웹 검색 같은 보정 행동을 취하는 방식을, Adaptive-RAG는 질문의 복잡도에 따라 검색 자체를 생략(단순 질문)·1회 검색(중간 복잡도)·다단계 검색(복합 질문)으로 나누는 방식을 제안했다. 둘 다 "검색할지 말지"를 판단하는 이 장 §5.1의 Retrieve 토큰 아이디어를 각자 다른 방향으로 정교화한 후속 연구다. (CRAG 논문 arXiv:2401.15884, Adaptive-RAG 논문 arXiv:2403.14403, NAACL 2024)
  • 전용 리랭킹 모델 생태계가 넓어졌다. §4에서 다룬 LLM 기반·크로스 인코더 기반 리랭킹 외에, Cohere처럼 리랭킹만을 위한 상용 모델(Rerank 시리즈)과 BGE·Jina 같은 오픈 웨이트 크로스 인코더가 다수 등장해 리랭킹 전용 모델을 선택하는 것이 실무에서 흔한 선택지가 됐다. 구체적인 모델별 순위·버전은 교체 주기가 빨라 이 노트에 고정하지 않는다 — 도입 시점에 Cohere 공식 Rerank 문서와 각 모델의 공식 카드로 다시 확인한다.

부록 A. 핵심 비교표

희소 검색 vs 밀집 검색

구분 희소 검색(BM25) 밀집 검색(FAISS)
문서·쿼리 표현 어휘 사전 크기의 희소 벡터(대부분 0) 저차원 밀집 벡터(임베딩)
판단 기준 키워드 일치 여부·빈도 의미적 유사도(코사인 유사도 등)
의미·문맥 포착 어렵다 가능하다
계산 비용 낮다 높다(대규모에서 IVF 등 인덱싱 필요)
강점 도메인 고유명사·전문 용어·정확한 문구 매칭이 중요한 문서 의미 기반 질의응답·추천처럼 폭넓은 이해가 중요한 경우

다중 질의 생성 vs HyDE

구분 다중 질의 생성 HyDE
변형 대상 질문 자체를 여러 관점으로 확장 질문에 대한 가상의 답변을 생성
검색에 쓰는 것 확장된 여러 질문 가상 답변 하나
적합한 상황 질문이 모호해 의도가 여러 갈래일 때 질문보다 답변이 검색 대상 문서와 어휘적으로 더 가까울 때
LLM 호출 횟수 질문 변형 1회 + 검색 N회 가상 문서 생성 1회 + 검색 1회 + 답변 생성 1회
단점 변형된 질문이 원 의도에서 벗어날 수 있다 가상 문서 생성에 드는 추가 지연·비용

LLM 기반 리랭킹 vs 크로스 인코더 기반 리랭킹

구분 LLM 기반 리랭킹 크로스 인코더 기반 리랭킹
사용 모델 Claude·GPT 등 고성능 범용 LLM BERT 계열의 작은 인코더 모델
정확도 높다(실측: 2022년 영업손실 문서 2개 모두 정확히 선별) LLM보다 낮을 수 있다(실측: 2개 중 1개만 정확)
속도·비용 느리고 비싸다 빠르고 저렴하다
평가 근거 제공 자연어로 가능(설명 가능) 점수만 산출, 자연어 근거 없음
도메인 파인튜닝 어렵다(상용 모델) 비교적 쉽고 저렴하다

부록 B. 추천 참고 자료

외부 자료 (Tier 1 공식·원논문, 검증 2026-09-12)

더 해보기 — 읽고 끝내지 않으려면

  • §1.1의 부모-자식 분할 코드를 자신이 가진 여러 챕터짜리 문서(사용설명서 등)에 적용해, 부모·자식 크기를 바꿔가며 검색 결과가 어떻게 달라지는지 관찰한다.
  • §3.3의 앙상블 검색에서 weights[0.8, 0.2], [0.2, 0.8]로 각각 바꿔 실행해 보고, 같은 질문에 대해 희소·밀집 비중이 결과 순위를 어떻게 바꾸는지 비교한다.
  • §5.2의 Self-RAG 코드에 원문의 다른 질문(예: 특정 계약 조건)을 넣어, Retrieve·ISREL·ISSUP·ISUSE 판단이 각 단계에서 어떻게 달라지는지 직접 추적한다.

본 책 연계 챕터

챕터 이 장이 다루지 않은 것
2장 §1·§3·§4 이 장이 전제로 쓰는 텍스트 임베딩·재귀적 문자 텍스트 분할·벡터 데이터베이스의 기초 원리
5장 §1 지식 그래프 기반의 검색 확장(그래프 RAG) — 이 장 §5가 다루는 Self-RAG와는 다른 축의 RAG 고도화
6장 §2 랭그래프로 반복·조건 분기·루프 개입을 실제 그래프 구조로 구현하는 방법 — 이 장 §5.2는 체인을 한 번 순차 실행하는 방식으로 단순화했다
7장 §2 에이전트가 도구 호출로 검색 여부를 스스로 판단하는 방식 — 이 장 §5.1의 Retrieve 토큰 판단과 비교할 대상
8장 §1 RAFT — 검색된 문서를 다루는 능력 자체를 모델에 학습시키는 파인튜닝 접근(표 4-1의 "모델 파인튜닝" 항목)
9장 §1·§3 이 장이 그대로 가져다 쓴 사전 학습 임베딩 모델(OpenAIEmbeddings)을 도메인에 맞게 직접 파인튜닝하는 방법

부록 C. 연습문제 풀이

  1. (부모-자식 분할 크기 설계) 조항 번호까지 정확히 찾아야 하므로 자식 문서는 조항 하나 정도의 크기(예: 100~300자)로 작게 잡아 정확한 매칭을 노리고, 부모 문서는 관련 조항들이 속한 절·장 단위(예: 1000~2000자)로 크게 잡아 검색된 조항의 앞뒤 맥락(정의 조항, 예외 조항 등)까지 함께 보여준다. §1.1에서 자식은 "정확한 정보 검색", 부모는 "넓은 맥락 제공"을 담당한다는 원리를 그대로 적용한 것이다.

  2. (다중 질의 생성 vs HyDE 판단) "이거 어떻게 해요?"는 "이거"가 무엇을 가리키는지조차 불분명한 극단적으로 모호한 질문이다. 다중 질의 생성은 원 질문을 여러 관점으로 확장하지만, 확장할 원 질문 자체에 정보가 거의 없어 생성되는 하위 질문들도 서로 무관하게 흩어질 위험이 크다. 반면 HyDE는 질문이 아니라 "그럴듯한 답변"을 상상해 만들기 때문에, LLM이 문맥(이전 대화 등)을 참고해 그나마 구체적인 가상 문서를 만들 여지가 있다. 다만 두 기법 모두 원 질문 자체가 심하게 모호한 경우의 근본 해법은 아니며, 우선 사용자에게 되묻거나 대화 맥락을 프롬프트에 포함하는 것이 더 근본적인 해법이다.

  3. (기술 문서 검색 비중 설계) 기술 문서는 API 이름·에러 코드·특정 버전 번호 같은 정확한 용어 매칭이 중요하므로 희소 검색(BM25) 비중을 높인다(예: weights=[0.7, 0.3]). 다만 기술 문서에도 같은 개념을 다른 용어로 설명하는 경우가 있으므로 밀집 검색을 완전히 배제하지는 않는다. §3.3에서 "정확한 용어 매칭이 중요한 도메인에서는 희소 검색에 더 높은 가중치"를 준다고 한 것과 같은 원리다.

  4. (실시간 채팅 리랭킹 선택) 실시간 채팅은 응답 속도가 핵심 요구사항이므로 크로스 인코더 기반 리랭킹이 더 적합하다. LLM 기반 리랭킹은 문서마다 LLM을 호출해야 해 지연이 크고 비용도 높다. 다만 §4.2의 실측 사례처럼 크로스 인코더는 LLM 기반보다 정확도가 낮을 수 있으므로, 오답의 비용이 매우 큰 질의 유형(예: 법률·의료 상담)에는 크로스 인코더로 빠르게 후보를 추린 뒤 소수의 최종 후보에만 LLM 기반 재검증을 추가하는 절충안을 고려할 수 있다.

  5. (Self-RAG 전원 Irrelevant 처리) §5.2의 process_query 메서드는 evaluate_relevance로 관련 문서를 걸러낸 뒤 if not relevant_contexts: 조건으로 관련 문서가 하나도 없는 경우를 확인한다. 이 조건이 참이면 generate_without_retrieval을 호출해 검색된 문서를 전혀 쓰지 않고 모델의 기존 지식만으로 답변을 생성한다 — 관련 없는 문서를 억지로 근거로 삼아 답변을 왜곡하는 대신, 검색이 실패했음을 인정하고 대안 경로로 넘어가는 방식이다.

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플   P 음성 버전 2026-08-05b